package dnl.toolbox.dto;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import dnl.toolbox.reflect.Classes;
import dnl.toolbox.reflect.Methods;

/**
 * A utility class for the conversion of domain objects into DTOs and vice
 * versa.
 * 
 * @author Daniel
 * 
 */
public class DtoConversions {

	/*
	 * Developer's note:
	 * 
	 * This class is designed for performance. The state (conext) is created and
	 * passed by the first call. This design choise was made in order to free
	 * converters from regarding the context. if there were no support for
	 * custom converters I'd put it in the converter. Also, because of that you
	 * may see similar code patterns in the different handler methods.
	 */

	private static final Logger logger = LoggerFactory.getLogger(DtoConversions.class);

	private ConvertersRegistry convertersCache = new ConvertersRegistry();

	/**
	 * Sets custom converters that will be used to convert objects. Each
	 * converter must specify accurately the types it converts.
	 * 
	 * @param customConverters
	 */
	public void setCustomConverters(List<DtoConverter> customConverters) {
		for (DtoConverter dtoConverter : customConverters) {
			this.convertersCache.registerConverter(dtoConverter);
		}
	}

	/**
	 * Copies values from one object to another while converting supported
	 * objects.
	 * 
	 * @param objA
	 *            an object which values will be read.
	 * @param objB
	 *            an object which values will be written.
	 */
	public void copy(Object objA, Object objB) {
		try {
			copyImpl(objA, objB, new ConversionContext(this));
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

	/**
	 * Copies values from one object to another while converting supported
	 * objects.
	 * 
	 * @param sourceObject
	 *            an object which values will be read.
	 * @param targetObject
	 *            an object which values will be written.
	 * @param context
	 *            a <code>ConversionContext</code> that is necessary in order to
	 *            keep consistent references accross conversions.
	 */
	public void copy(Object dto, Object domainObject, ConversionContext context) {
		try {
			copyImpl(dto, domainObject, context);
		} catch (Exception e) {
			e.printStackTrace();
		}
	}

	/**
	 * Converts the given <code>Object</code> to the given type.
	 * 
	 * @param obj
	 *            an object to convert
	 * @param targetType
	 *            target conversion type.
	 * @return a converted object
	 */
	public Object convert(Object obj, Class<?> targetType) {
		if (obj == null)
			return null;
		DtoConverter converter = convertersCache.getConverter(obj.getClass(), targetType);
		return converter.convert(obj, new ConversionContext(this));
	}

	/**
	 * Converts the given <code>Object</code> to the given type.
	 * 
	 * @param obj
	 *            an object to convert
	 * @param targetType
	 *            target conversion type.
	 * @param context
	 *            a <code>ConversionContext</code> that is necessary in order to
	 *            keep consistent references accross conversions.
	 * @return a converted object
	 */
	public Object convert(Object obj, Class<?> targetType, ConversionContext context) {
		if (obj == null)
			return null;
		DtoConverter converter = convertersCache.getConverter(obj.getClass(), targetType);
		return converter.convert(obj, context);
	}

	/**
	 * Converts a collection, with generics support.
	 * 
	 * @param <T>
	 *            source generic type
	 * @param <V>
	 *            target generic type
	 * @param coll
	 *            a <code>Collection</code> to convert.
	 * @return a converted <code>Collection</code>.
	 */
	@SuppressWarnings("unchecked")
	public <T, V> Collection<V> convertCollection(Collection<T> coll) {
		Collection<V> result = null;
		if (List.class.isAssignableFrom(coll.getClass())) {
			result = new ArrayList();
		} else if (Set.class.isAssignableFrom(coll.getClass())) {
			result = new HashSet();
		} else {
			throw new UnsupportedOperationException();
		}
		if (coll.size() != 0) {
			// have same context fro all conversions
			ConversionContext context = new ConversionContext(this);
			DtoConverter converter = null;
			for (Object object : coll) {
				converter = convertersCache.getConverter(object.getClass());
				V convertedObject = (V) converter.convert(object, context);
				result.add(convertedObject);
			}
		}
		return result;
	}

	public <V> List<V> convertList(List<?> coll, Class<?> targetType) {
		List<V> result = new ArrayList<V>();
		pourInto(coll, targetType, result);
		return result;
	}

	public <V> Set<V> convertSet(List<?> coll, Class<?> targetType) {
		Set<V> result = new HashSet<V>();
		pourInto(coll, targetType, result);
		return result;
	}

	@SuppressWarnings("unchecked")
	private <V> void pourInto(Collection<?> coll, Class<?> targetType, Collection<V> result) {
		if (coll.size() != 0) {
			// have same context fro all conversions
			ConversionContext context = new ConversionContext(this);
			DtoConverter converter = null;
			for (Object object : coll) {
				converter = convertersCache.getConverter(object.getClass(), targetType);
				V convertedObject = (V) converter.convert(object, context);
				result.add(convertedObject);
			}
		}
	}

	/**
	 * Performs the actual copying.
	 * 
	 * @param objA
	 *            an object to copy from.
	 * @param objB
	 *            an object to copy into.
	 */
	private void copyImpl(Object objA, Object objB, ConversionContext context) throws Exception {
		if (context == null) {
			context = new ConversionContext(this);
		}
		List<Method> getters = Methods.getAttributesGetters(objA.getClass(), false);
		for (Method method : getters) {
			Class<?> returnType = method.getReturnType();
			// if(method.getName().startsWith("getColl")){
			// }
			if (Collection.class.isAssignableFrom(returnType)) {
				// if(method.getName().startsWith(""))
				handleCollection(method, objA, objB, context);
			} else if (returnType.isArray()) {
				handleArray(method, objA, objB, context);
			} else {
				copySingleObjectImpl(method, objA, objB, context);
			}

		}
	}

	/**
	 * Uses the given getter to extract a value from objA and then injects it
	 * into objB.
	 * 
	 * @param getter
	 * @param objA
	 * @param objB
	 */
	private void copySingleObjectImpl(Method getter, Object objA, Object objB,
			ConversionContext context) throws Exception {
		String propertyName = Methods.getPropertyName(getter);
		Class<?> targetType = getPropertyType(propertyName, objB);
		if (targetType == null) {
			return;
		}

		Object originalObject = getter.invoke(objA, (Object[]) null);
		Object object = originalObject;
		DtoConverter converter = null;

		if (originalObject != null) {
			if (Modifier.isAbstract(targetType.getModifiers())) {
				converter = convertersCache.getConverter(originalObject.getClass());
			} else {
				converter = convertersCache.getConverter(originalObject.getClass(), targetType);
			}
		}

		if (object != null && converter != null) {
			Object cachedConvertedObject = context.retrieveConvertedObject(object);
			if (cachedConvertedObject != null) {
				object = cachedConvertedObject;
			} else {
				object = converter.convert(object, context);
				// context.registerConvertedObject(originalObject, object);
			}
		}
		inject(propertyName, objB, object);
	}

	@SuppressWarnings("unchecked")
	private static Collection newCollection(Class<?> collectionClass) throws Exception {
		if (Classes.hasPublicEmptyConstructor(collectionClass)) {
			return (Collection) collectionClass.newInstance();
		} else {
			if (List.class.isAssignableFrom(collectionClass)) {
				return new ArrayList();
			} else if (Set.class.isAssignableFrom(collectionClass)) {
				return new HashSet();
			}
		}
		throw new UnsupportedOperationException();
	}

	/**
	 * Assuming the given getter returns a Collection this method will check if
	 * its elements has a converter and use it when copying.
	 * 
	 * @param getter
	 * @param objA
	 * @param objB
	 * @param toDto
	 */
	@SuppressWarnings("unchecked")
	private void handleCollection(Method getter, Object objA, Object objB, ConversionContext context)
			throws Exception {
		Collection newCollection = null;

		Collection coll = (Collection) getter.invoke(objA, (Object[]) null);
		if (coll == null) {
			return;
		}
		/*
		 * if(org.hibernate.collection.AbstractPersistentCollection.class.isAssignableFrom
		 * (coll.getClass())){ AbstractPersistentCollection hibernateColl =
		 * (AbstractPersistentCollection) coll;
		 * if(!hibernateColl.wasInitialized()){ return; } }
		 */

		Class<?> collectionClass = coll.getClass();
		newCollection = newCollection(collectionClass);
		if (!coll.isEmpty()) {
			for (Object object : coll) {
				DtoConverter converter = convertersCache.getConverter(object.getClass());
				if (converter != null) {
					Object cached = context.retrieveConvertedObject(object);
					if (cached != null) {
						newCollection.add(cached);
					} else {
						Object converted = converter.convert(object, context);
						newCollection.add(converted);
						// context.registerConvertedObject(object, converted);
					}
				} else {
					newCollection.add(object);
				}
			}
		}
		String propertyName = Methods.getPropertyName(getter);
		inject(propertyName, objB, newCollection);

	}

	/**
	 * 
	 * @param getter
	 * @param objA
	 * @param objB
	 * @param toDto
	 */
	private void handleArray(Method getter, Object objA, Object objB, ConversionContext context) {
		String propertyName = Methods.getPropertyName(getter);
		Object target = null;
		try {
			Object array = getter.invoke(objA, (Object[]) null);
			Class<?> srcComponentType = array.getClass().getComponentType();

			Class<?> pType = getPropertyType(propertyName, objB);
			Class<?> targetComponentType = pType.getComponentType();

			DtoConverter converter = convertersCache.getConverter(srcComponentType,
					targetComponentType);
			if (srcComponentType.isPrimitive() || converter == null) {
				target = array;
			} else {
				Object[] arr = (Object[]) array;
				Object[] targetArr = (Object[]) java.lang.reflect.Array.newInstance(
						targetComponentType, arr.length);
				for (int i = 0; i < targetArr.length; i++) {
					Object cached = context.retrieveConvertedObject(arr[i]);
					if (cached != null) {
						targetArr[i] = cached;
					} else {
						targetArr[i] = converter.convert(arr[i], context);
						// context.registerConvertedObject(arr[i],
						// targetArr[i]);
					}
				}
				target = targetArr;
			}
			inject(propertyName, objB, target);
		} catch (Exception e) {
			throw new IllegalStateException(
					"Domain objects and their corresponding DTOs must be public and "
							+ "have a public no-args constructor and public accessor methods.", e);
		}
	}

	/**
	 * Gets the type that matches a given property in a given
	 * <code>Object</code>.
	 * 
	 * @param propertyName
	 *            the name of a class property. By property we mean a class
	 *            member that is identified only by a getter and setter.
	 * @param obj
	 * @return
	 */
	private Class<?> getPropertyType(String propertyName, Object obj) {
		Method targetSetter = Methods.getSetter(obj.getClass(), propertyName, true);
		if (targetSetter == null) {
			logger.info("No setter for " + propertyName + " in class " + obj.getClass());
			return null;
		}
		Class<?>[] parameterTypes = targetSetter.getParameterTypes();
		return parameterTypes[0];
	}

	/**
	 * Injects <code>value</code> into <code>objB</code> using the setter of the
	 * given <code>propertyName</code>.
	 * 
	 * @param propertyName
	 * @param objB
	 * @param value
	 * @throws Exception
	 */
	private static void inject(String propertyName, Object objB, Object value) throws Exception {
		Method setter = Methods.getSetter(objB.getClass(), propertyName, true);
		if (setter != null) {
			try {
				setter.invoke(objB, value);
			} catch (InvocationTargetException ite) {
				if (ite.getTargetException() instanceof Exception) {
					Exception e = (Exception) ite.getTargetException();
					throw e;
				}
				throw ite;
			}
		} else {
			if (logger.isDebugEnabled()) {
				logger.debug("No setter for property: " + objB.getClass().getName() + "."
						+ propertyName);
			}
		}
	}
}
